跳到主要内容

Java JVM学习-创建一个对象

创建对象的步骤

平时创建一个对象只需要 new。然而对象的创建到底经历了哪些呢?实际上只不过仅仅的 3步就完成了。先来看看完整的创建过程,再来一步一步的分析。

加载类元信息(类加载检查)

要创建对象肯定首先要知道是什么、有没有。所以首先就是找到对象的类信息。类信息都是放到方法区的。

从这里看出类信息放到方法区是很有必要的,因为每个线程每个方法都可能需要这些信息。

虚拟机遇到一条 new 指令,首先去检查这个指令的参数能否在 Metaspace 的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的关是否已经被加载,解析和初始化(即判断类元信息是否存在)。

如果没有,那么在双亲委派模式下,使用当前类加载器以 ClassLoader + 包名 + 类名 为 Key进行查找对应的 .class 文件。如果没有找到文件,则抛出 ClassNotFoundException 异常如果找到,则进行类加载,并生成对应的 Class类对象

为对象分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。 而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的

如果内存规整(指针碰撞法)

如果内存是规整的,那么虚拟机将采用的是指针碰撞法(Bump The Pointer)来为对象分配内存。意思是所有用过的内存在一边 ,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。

如果垃圾收集器选择的是 Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带有 compact(整理)过程的收生器时,就是使用指针碰撞。

如果内存不规整(空闲列表)

如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。

意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为 “空闲列表(Free List)”。

补充:处理并发安全问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

都知道对象是放到 Java 堆中的,同时对象是随时都在创建的,当多个线程运行的时候就有可能把对象放到同一个地方,那么肯定就会有线程拿到不是他想要的对象。

1、CAS + 失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。

2、TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。虚拟机是否使用根据 -XX:+/-UseTLAB 参数设置。

如下图:

所以虽然 Java堆是线程共享的,但也有可能一些内存实际上是线程独享的。

补充:对象分配过程 TLAB

什么是 TLAB?

  • 从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。
  • 多线程同时分配内存时,使用 TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。

为什么有 TLAB(Thread Local Allocation Buffer)?

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在 JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。

TLAB 的再说明:

尽管不是所有的对象实例都能够在 TLAB中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。 在程序中,开发人员可以通过选项 -XX:UseTLAB 设置是否开启 TLAB空间。

默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden空间的 1%,可以通过选项 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden空间的百分比大小。

一旦对象在 TLAB 空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden空间中分配内存。

对象执行初始化

现在对象的位置知道了,空间也分配了。但是里面还是空荡荡的一块。现在就要创造出内容来。

将对象的所属类(即类的元数据信息)、对象的 HashCode和对象的 GC信息、锁信启等数据存储在对象的对象头中。这个过程的具体设置方式取决于 JVM实现。

设置属性的零值

因为在 TLAB模式下,初始化属性的零值已经设置过了,所以这里有可能不需要设置。只有设置了值,我们才能在调用的时候才能获取到正确的值。

如下所示:

public class Test {
int data;
String data2;
boolean data3;

public static void main(String[] args) {
Test test = new Test();
System.out.println(test.data1);
System.out.println(test.data2);
System.out.println(test.data3);
}
}

打印出来分别是 0、null、false。

之所以能打印出来这些,就是因为这里的初始化。如果没有这一步有可能data1打印出来的就不是0。data2打印出来的就更加不知道是什么了。

填充对象头信息

对象里面要存必要的东西,比如对象类型信息。如果是 Java数组还要记录数组的长度。以及一些其他信息,如下图所示:

具体对象头包含了啥,看下面对象头那节

执行 init 进行初始化

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

因此一般来说初始化由字节码中是否跟随有 invokespecial 指令所决定, new 指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化。这样一个真正可用的对象才算完全创建出来。

这一步主要初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

补充:对象的内存布局

在 HotSpot 虚拟机中,一个对象在内存中的布局包含三块内容:对象头、实例数据、对其填充。

对象头包含的信息

对象头主要包含两部分信息

第一部分是用于存储对象自身的运行时数据(官方称该部分为 “Mark Word”)

  • 哈希码
  • GC分代年龄
  • 锁状态标志
  • 线程只有的锁
  • 偏向线程ID
  • 偏向时间戳

该部分的长度由虚拟机决定,在32位和64位的虚拟机中该长度分别32位和64位。不过因为对象需要存储的运行时数据往往很多,其实已经是超过32位、64位 Bitmap所能记录的限度,所以 Mark Word被设计成一个非固定的数据结构以便在极小的空间存储尽量多的数据(根据对象的状态复用自己的存储空间)。

比如说在32位的 HotSpot虚拟机中,无锁状态下,锁标志为01,25bit用于存储对象的 hashCode,4bit用于存储 GC分代年龄,1bit因为为无所状态所以固定为0。

别忘了,在学习 synchronized 时,拿对象当锁,就是用的这块地方来做锁升级

对象头的另一部分为类型指针

即指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是属于哪个类的实例。

不过并不是所有虚拟机都会在对象数据上保留类型指针(还可能保存的是句柄,看下面)。另外,如果对象是一个 Java数组,那么对象头中还必须有一块用于记录数组长度的数据(因为虚拟机可以通过普通 Java对象的元数据信息确定对象的大小,但从数组的元数据信息中无法获取数组的大小)。

堆中的实例数据

该部分用于存储对象真正的有效信息,也就是程序代码中所定义的各种类型字段内容。不管是从父类继承下来的,还是子类自身定义的,都会进行记录。

具体的存储顺序会受到虚拟机分配策略参数和字段在源码中的定义顺序影响。

Hotspot 虚拟机默认的分配策略是 longs/doubles、ints、shots/chars、bytes/booleans、oops(对象指针),从分配策略中可以看出同一宽度的字段总是被分配在一起。在满足这个前提条件下,在父类的中定义的变量会出现在子类之前。

如果 CompactFields 参数为 true(默认情况),那么子类的宽度较窄的变量也可能会插入到父类的变量空隙之中。

对齐填充部分

这部分主要是做到占位符的作用,没有特别含义。因为 HotSpot VM 的自动内存管理系统要求对象的起始地址必须是 8字节的整数倍,那么对象的大小也必须是 8字节的整数倍。因此在对象实际大小不满足 8字节的整数倍时,就需要这个部分进行占位填充。

如何进行对象的访问定位?

上面创建好 Java 对象后,如何进行对象的访问定位呢?

虚拟机栈会存储方法的执行过程中的局部变量表,如果变量为对象的话,栈中存储的就是该对象的引用,而具体的对象则被创建在堆当中,对象所对应的类信息则存储在方法区。

在 Java 程序中需要通过栈上 reference 数据来操作堆上的具体对象。

对象访问的方式则取决于虚拟机实现来决定。目前主要有使用句柄和直接指针两种类型。

直接指针

使用直接指针访问,Java堆对象的布局中必须考虑如何放置访问类型数据的相关信息,而 reference中存储的直接就是对象地址。

如下图所示:

使用句柄

如果使用句柄的方式访问,那么虚拟机会在 Java堆上划分出一部分区域作为句柄池,reference指向对象的句柄。

句柄中存储了对象实例数据地址和类型数据地址(这也就是之前在介绍对象头所说的并不是所有虚拟机都会在对象数据上保留类型指针)。

如下图所示:

使用句柄方式的最大好处就是存储的是稳定的句柄地址,当对象被移动(垃圾回收有可能会移动对象),reference 本身的值不会被修改。而使用直接指针的方式访问会更加快,它节省了一次指针定位的时间开销,不过当对象被移动时,reference值会被改动。

Reference

参考资料 快速理解JVM创建对象的步骤! 参考资料 jvm笔记-new一个对象的过程 参考资料 Java内存区域-对象的创建